<?php

require_once("finefs/class.FineFS.php");
require_once("finefs/utils/except.ApplicationException.php");
require_once("finefs/utils/except.IOException.php");
require_once("class.FineDBDocument.php");
require_once("class.FineDBProtoDocument.php");

/**
 * Object for the management of FineDB's data collection.
 *
 * @author	Amaury Bouchard <amaury.bouchard@finemedia.fr>
 * @copyright	Copyright (c) 2010, FineMedia
 * @package	FineDB
 * @subpackage	lib
 * @version	$Id: class.FineDBCollection.php 8 2011-09-26 10:38:52Z amaury.bouchard $
 */
abstract class FineDBCollection {
	/** Object for the whole database management. */
	private $_base = null;
	/** Name of this collection. */
	private $_name = null;
	/** Definition of collection's indexes. */
	private $_indexes = null;
	/** Definition of collection's mandatory meta-data. */
	private $_mandatory = null;
	/** Objet de gestion des documents. */
	private $_documentObjectName = "FineDBProtoDocument";
	/** Path on FineFS for this collection. */
	private $_path = null;

	/* **************** CONSTRUCTION METHODS ******************* */
	/**
	 * Constructor.
	 * @param	FineDB	$base			Database object.
	 * @param	string	$collection		Name of this collection.
	 * @param	array	$indexes		Definition of this collection's indexes.
	 * @param	array	$mandatory		Definition of this collection's mandatory meta-data.
	 * @param	string	$documentObjectName	(optional) Name of the object type for documents.
	 */
	public function __construct($base, $collection, $indexes, $mandatory, $documentObjectName="FineDBProtoDocument") {
		$this->_base = $base;
		$this->_name = $collection;
		$this->_indexes = $indexes;
		$this->_mandatory = $mandatory;
		if (is_subclass_of($documentObjectName, "FineDBDocument"))
			$this->_documentObjectName = $documentObjectName;
		$this->_path = $base->getPath() . "/" . rawurlencode($collection);
	}
	/**
	 * Set the name of documents object.
	 * @param	string	$documentObjectName	Name of the object type for documents.
	 * @throws	ApplicationException	If the given name is not a child type of FineDBDocument.
	 */
	public function setDocumentObject($documentObjectName) {
		if (!is_subclass_of($documentObjectName, "FineDBDocument"))
			throw new ApplicationException("Not a FineDBDocument object.", ApplicationException::API);
		$this->_documentObjectName = $documentObjectName;
	}
	/**
	 * Returns the base of this collection.
	 * @return	FineDB	The base.
	 */
	public function getBase() {
		return ($this->_base);
	}

	/* *********************** DOCUMENT MANAGEMENT METHODS ******************* */
	/**
	 * Fetch a document from its identifier.
	 * @param	string	$id	Identifier of the document.
	 * @return	FineDBdocument	An object to manage the document.
	 * @throws	IOException	Problem to get the document.
	 */
	protected function _get($id) {
		// retreive collection definition
		$path = $this->_path . "/data/" . $id;
		$meta = $this->_base->getFineFS()->getData($path);
		$data = json_decode($meta['data'], true);
		unset($meta['data']);
		// create a document object
		$document = new $this->_documentObjectName($this, $id, $data, $meta);
		return ($document);
	}
	/**
	 * Remove a document.
	 * @param	string	$documentId	Document's identifier.
	 * @throws	IOException	Unknown document.
	 */
	protected function _removeDocument($documentId) {
		$docPath = $this->_path . "/data/" . $documentId;
		// todo: use lock when FineFS provides it
		// get document metadata
		$meta = $this->_base->getFineFS()->getInfo($docPath);
		// remove old indexes
		foreach ($this->_indexes as $indexName => $indexType) {
			$indexType = $this->_indexes[$indexName];
			$indexPath = $this->_path . "/index/" . rawurlencode($indexName);
			if ($indexType == "unique") {
				// todo: lock
				// remove the previous index
				$this->_base->getFineFS()->remove($indexPath . "/" . rawurlencode($meta[$indexName]));
			} else if ($indexType == "multiple") {
				// todo: use a lock when FineFS will provide it, to ensure to not write on the index file while somebody else is doing it
				// remove the document from this value
				$filepath = $indexPath . "/list/" . rawurlencode($meta[$indexName]);
				try {
					$indexData = $this->_base->getFineFS()->getData($filepath);
					$indexData = explode("\n", $indexData['data']);
					if (($offset = array_search($documentId, $indexData)) !== false)
						unset($indexData[$offset]);
					$this->_base->getFineFS()->putData($filepath, implode("\n", $indexData));
				} catch (Exception $e) { }
			}
		}
		// remove document
		$this->_base->getFineFS()->remove($docPath);
	}
	/**
	 * Update a document.
	 * @param	string	$documentId	Document's identifier.
	 * @param	mixed	$data		Document's data.
	 * @param	array	$meta		Document's metadata.
	 * @param	array	$modifiedMeta	List of modified metadata.
	 * @throws	ApplicationException	Missing mandatory metadata.
	 * @throws	IOException		Unknown document.
	 */
	public function _updateDocument($documentId, $data, $meta, $modifiedMeta) {
		$docPath = $this->_path . "/data/" . $documentId;
		// todo: use lock when FineFS provides it
		// check metadata definition
		foreach ($this->_mandatory as $mandatory) {
			if (!isset($meta[$mandatory]))
				throw new ApplicationException("Missing mandatory metadata '$mandatory'.", ApplicationException::API);
		}
		// remove old indexes
		// loop on all modified metadata
		foreach ($modifiedMeta as $indexName => $oldValues) {
			$indexType = $this->_indexes[$indexName];
			if (!$indexType)
				continue;
			if (!is_array($oldValues))
				$oldValues = array($oldValues);
			$indexPath = $this->_path . "/index/" . rawurlencode($indexName);
			if ($indexType == "unique") {
				// todo: use a lock when FineFS will provide it, to ensure to not write on the index file while somebody else is doing it
				// remove the previous index
				foreach ($oldValues as $oldValue)
					$this->_base->getFineFS()->remove($indexPath . "/" . rawurlencode($oldValue));
			} else if ($indexType == "multiple") {
				// loop on all values of this index
				foreach ($oldValues as $oldValue) {
					// remove the document for this value
					$filepath = $indexPath . "/list/" . rawurlencode($oldValue);
					try {
						$indexData = $this->_base->getFineFS()->getData($filepath);
						$indexData = explode("\n", $indexData['data']);
						if (count($indexData) == 1 && $indexData[0] == $documentId) {
							// was the only document with this value for this index
							// remove the values file
							$this->_base->getFineFS()->remove($filepath);
							// remove the value from values list file
							// todo: lock
							$valuesFilepath = $indexPath . "/values";
							$indexList = $this->_base->getFineFS()->getData($valuesFilepath);
							$indexList = explode("\n", $indexList['data']);
							if (($offset = array_search($oldValue, $indexList)) !== false)
								unset($indexList[$offset]);
							sort($indexList);
							$this->_base->getFineFS()->putData($valuesFilepath, implode("\n", $indexList));
						} else {
							// there is other documents with this value for this index
							// updating the values file
							if (($offset = array_search($documentId, $indexData)) !== false) {
								unset($indexData[$offset]);
								$this->_base->getFineFS()->putData($filepath, implode("\n", $indexData));
							}
						}
					} catch (Exception $e) { }
				}
			}
		}
		// indexes creation
		foreach ($this->_indexes as $indexName => $indexType) {
			if (empty($meta[$indexName]) || !array_key_exists($indexName, $modifiedMeta))
				continue;
			$isArrayIndex = is_array($meta[$indexName]);
			if (!is_array($meta[$indexName]))
				$meta[$indexName] = array($meta[$indexName]);
			$indexPath = $this->_path . "/index/" . rawurlencode($indexName);
			if ($indexType == "unique") {
				// loop on all values of this index
				foreach ($meta[$indexName] as $indexValue) {
					if (!is_string($indexValue) && !is_numeric($indexValue))
						continue;
					// search for a previously created index
					try {
						$info = $this->_base->getFineFS()->getInfo($indexPath . "/" . rawurlencode($indexValue));
					} catch (Exception $e) { }
					if ($info)
						throw new IOException("Duplicate unique index '$indexName' with value '$indexValue'.", IOException::UNWRITABLE);
					// index creation
					$this->_base->getFineFS()->link($indexPath . "/" . rawurlencode($indexValue), $docPath);
				}
			} else if ($indexType == "multiple") {
				// todo: use a lock when FineFS will provide it, to ensure to not write on the index file while somebody else is doing it
				// add the value to the list of index's values
				$filepath = $indexPath . "/values";
				try {
					$indexData = $this->_base->getFineFS()->getData($filepath);
					$indexData = explode("\n", $indexData['data']);
				} catch (Exception $e) {
					$indexData = array();
				}
				// loop on all values of this index
				foreach ($meta[$indexName] as $indexValue) {
					if (!is_string($indexValue) && !is_numeric($indexValue))
						continue;
					if (!in_array($indexValue, $indexData))
						$indexData[] = $indexValue;
				}
				sort($indexData);
				$this->_base->getFineFS()->putData($filepath, implode("\n", $indexData));
				// loop on all values of this index
				foreach ($meta[$indexName] as $indexValue) {
					if (!is_string($indexValue) && !is_numeric($indexValue))
						continue;
					// add the document for this value
					$filepath = $indexPath . "/list/" . rawurlencode($indexValue);
					try {
						$indexData = $this->_base->getFineFS()->getData($filepath);
						$indexData = explode("\n", $indexData['data']);
						if (!in_array($documentId, $indexData))
							$indexData[] = $documentId;
					} catch (Exception $e) {
						$indexData = array($documentId);
					}
					$this->_base->getFineFS->putData($filepath, implode("\n", $indexData));
				}
			}
			if (!$isArrayIndex)
				$meta[$indexName] = $meta[$indexName][0];
		}
		// document update
		// todo: use a conditional creation when FineFS will provide it, to ensure to not modify a document that was modified meanwhile
		$this->_base->getFineFS()->putData($docPath, json_encode($data), $meta);
	}
	/**
	 * Create a new document.
	 * @param	mixed	$data	Document's data.
	 * @param	array	$meta	(optional) Document's metadata.
	 * @return	FineDBDocument	An object to manage the new document.
	 * @throws	ApplicationException	Bad parameter.
	 * @throws	IOException		Problem during document creation.
	 */
	protected function _add($data, $meta=null) {
		// check parameters
		$meta = isset($meta) ? $meta : array();
		// check metadata definition
		foreach ($this->_mandatory as $mandatory) {
			if (!isset($meta[$mandatory]) || (is_array($meta[$mandatory]) && !count($meta[$mandatory])))
				throw new ApplicationException("Missing mandatory metadata '$mandatory'.", ApplicationException::API);
		}
		// generate a unique identifier
		$documentId = FineDB::hash();
		$meta['_id'] = $documentId;
		$docPath = $this->_path . "/data/" . $documentId;
		// indexes creation
		foreach ($this->_indexes as $indexName => $indexType) {
			if (empty($meta[$indexName]))
				continue;
			$isArrayIndex = is_array($meta[$indexName]);
			if (!is_array($meta[$indexName]))
				$meta[$indexName] = array($meta[$indexName]);
			$indexPath = $this->_path . "/index/" . rawurlencode($indexName);
			if ($indexType == "unique") {
				// loop on all values of this index
				foreach ($meta[$indexName] as $indexValue) {
					if (!is_string($indexValue) && !is_numeric($indexValue))
						continue;
					// search for a previously created index
					$info = null;
					try {
						$info = $this->_base->getFineFS()->getInfo($indexPath . "/" . rawurlencode($indexValue));
					} catch (Exception $e) { }
					if ($info)
						throw new IOException("Duplicate unique index '$indexName' width value '$indexValue'.", IOException::UNWRITABLE);
					// index creation
					$this->_base->getFineFS()->link($indexPath . "/" . rawurlencode($indexValue), $docPath);
				}
			} else if ($indexType == "multiple") {
				// todo: use a lock when FineFS will provide it, to ensure to not write on the index file while somebody else is doing it
				// add the value to the list of index's values
				$filepath = $indexPath . "/values";
				try {
					$indexData = $this->_base->getFineFS()->getData($filepath);
					$indexData = explode("\n", $indexData['data']);
				} catch (Exception $e) {
					$indexData = array();
				}
				// loop on all values of this index
				foreach ($meta[$indexName] as $indexValue) {
					if (!is_string($indexValue) && !is_numeric($indexValue))
						continue;
					if (!in_array($indexValue, $indexData))
						$indexData[] = $indexValue;
				}
				sort($indexData);
				$this->_base->getFineFS()->putData($filepath, implode("\n", $indexData));
				// loop on all values of this index
				foreach ($meta[$indexName] as $indexValue) {
					if (!is_string($indexValue) && !is_numeric($indexValue))
						continue;
					// add the document for this value
					$filepath = $indexPath . "/list/" . rawurlencode($indexValue);
					try {
						$indexData = $this->_base->getFineFS()->getData($filepath);
						$indexData = explode("\n", $indexData['data']);
						if (!in_array($documentId, $indexData))
							$indexData[] = $documentId;
					} catch (Exception $e) {
						$indexData = array($documentId);
					}
					$this->_base->getFineFS()->putData($filepath, implode("\n", $indexData));
				}
			}
			if (!$isArrayIndex)
				$meta[$indexName] = $meta[$indexName][0];
		}
		// document creation
		// todo: use a conditional creation when FineFS will provide it, to ensure to not create a data that already exists
		$this->_base->getFineFS()->putData($docPath, json_encode($data), $meta);
		// create a new collection object
		$document = new $this->_documentObjectName($this, $documentId, $data, $meta);
		return ($document);
	}

	/* *********************** SEARCH METHODS ***************** */
	/**
	 * Returns a list of all documents.
	 * @param	string|array	$sort		(optional) Hash of sort criteria.
	 * @param	int		$limitOffset	(optional) Offset of the first returned document. Can be negative to
	 *						specify an offset from the end of the list. 0 by default.
	 * @param	int		$nbrDocuments	(optional) Number of documents to return. By default, returns all documents after the offset.
	 * @return	array	List of documents' identifiers.
	 */
	protected function _getList($sort=null, $limitOffset=null, $nbrDocuments=null) {
		$list = $this->_base->getFineFS()->getList($this->_path . "/data/*");
		if (!is_null($limitOffset) || !is_null($nbrDocuments))
			return (array_slice($list, (is_null($limitOffset) ? 0 : $limitOffset), $nbrDocuments)); 
		$docList = array();
		foreach ($list as $docId => $docMeta)
			$docList[] = substr($docId, strlen($this->_path . "/data/"));
		return ($docList);
	}
	/**
	 * Get a document from an unique index.
	 * @param	string	$index	Name of the index.
	 * @param	string	$value	Value of the index.
	 * @return	FineDBDocument	The resulting document.
	 * @throws	ApplicationException	Not a unique index.
	 * @throws	IOException		Read error.
	 */
	protected function _getFromIndex($index, $value) {
		if ($this->_indexes[$index] != "unique")
			throw new ApplicationException("Not a unique index ($index).", ApplicationException::API);
		$path = $this->_path . "/index/" . rawurlencode($index) . "/" . rawurlencode($value);
		try {
			$meta = $this->_base->getFineFS()->getData($path);
		} catch (ApplicationException $e) {
			throw new IOException("Unable to find matching file.", IOException::NOT_FOUND);
		}
		$data = json_decode($meta['data'], true);
		unset($meta['data']);
		$document = new $this->_documentObjectName($this, $meta['_id'], $data, $meta);
		return ($document);
	}
	/**
	 * Get a document from some criteria. If many document match these criteria, the first is returned.
	 * @param	array	$criteria	Hash of search criteria.
	 * @return	FineDBDocument	The resulting document.
	 * @throws	ApplicationException	Parameter error.
	 * @throws	IOException		Read error.
	 */
	protected function _find($criteria) {
		$idList = $this->search($criteria, null, 0, 1);
		if (empty($idList[0]))
			throw new IOException("Unable to find matching file.", IOException::NOT_FOUND);
		return ($this->get($idList[0]));
	}
	/**
	 * Search a list of documents from some criteria.
	 * @param	array		$criteria	Hash of search criteria.
	 * @param	string|array	$sort		(optional) Hash of sort criteria.
	 * @param	int		$limitOffset	(optional) Offset of the first returned document. Can be negative to
	 *						specify an offset from the end of the list. 0 by default.
	 * @param	int		$nbrDocuments	(optional) Number of documents to return. By default, returns all documents after the offset.
	 * @return	array		List of document identifiers.
	 * @throws	ApplicationException	Parameter error.
	 * @throws	IOException		Read error.
	 */
	protected function _search($criteria, $sort=null, $limitOffset=null, $nbrDocuments=null) {
		$identifiers = null;
		foreach ($criteria as $critKey => $critValue) {
			// check criterion validity
			if (is_string($critKey) && is_string($critValue)) {
				// criterion like: "email" => "toto@toto.com"
				$indexName = $critKey;
				$search = $critValue;
			} else if (is_numeric($critKey) && is_array($critValue) && is_string($critValue['index']) &&
				   (is_string($critValue['search']) || is_array($critValue['search']))) {
				// criterion like: array('index' => "email", 'search' => "toto@toto.com")
				// or: array('index' => "email", 'search' => array("toto@toto.com", "titi@titi.com"))
				// or: array('index' => "email", 'search' => array('=' => "toto@toto.com", '=' => "titi@titi.com"))
				$indexName = $critValue['index'];
				$search = $critValue['search'];
			} else
				throw new ApplicationException("Bad search expression.", ApplicationException::API);
			// check index
			$indexType = $this->_indexes[$indexName];
			if ($indexType != "unique" && $indexType != "multiple")
				throw new ApplicationException("Not an index ($indexName).", ApplicationException::API);
			// processing
			if (is_string($search)) {
				// get index
				$indexPath = $this->_path . "/index/" . rawurlencode($indexName);
				if ($indexType == "unique") {
					$indexPath .= "/" . rawurlencode($search);
					$indexData = $this->_base->getFineFS()->getInfo($indexPath);
					$indexData = array($indexData['_id']);
				} else { // $indexType == "multiple"
					$indexPath .= "/list/" . rawurlencode($search);
					$indexData = $this->_base->getFineFS()->getData($indexPath);
					$indexData = explode("\n", $indexData['data']);
				}
				// compute the intersection of these indexes with the previously fetched ones
				if (is_null($identifiers))
					$identifiers = $indexData;
				else
					$identifiers = array_intersect($identifiers, $indexData);
				if (is_null($identifiers) || !count($identifiers))
					return (array());
			} else if (is_array($search)) {
				$searchIdentifiers = null;
				// process all searches of this criterion
				foreach ($search as $searchType => $searchValue) {
					// check the type of search
					if ($searchType !== "=" && $searchType !== "<" && $searchType !== ">" &&
					    $searchType !== "<=" && $searchType !== ">=")
						$searchType = "=";
					if ($searchType != "=")
						throw new ApplicationException("Unsupported search type ($searchType).", ApplicationException::API);
					// get index
					$indexPath = $this->_path . "/index/" . rawurlencode($indexName);
					if ($indexType == "unique") {
						$indexPath .= "/" . rawurlencode($searchValue);
						$indexData = $this->_base->getFineFS()->getInfo($indexPath);
						$indexData = array($indexData['_id']);
					} else { // $indexType == "multiple"
						$indexPath .= "/list/" . rawurlencode($searchValue);
						$indexData = $this->_base->getFineFS()->getData($indexPath);
						$indexData = explode("\n", $indexData['data']);
					}
					// compute the intersection of these indexes with the previously fetched ones
					if (is_null($searchIdentifiers))
						$searchIdentifiers = $indexData;
					else
						$searchIdentifiers = array_unique(array_merge($searchIdentifiers, $indexData));
				}
				if (is_null($identifiers))
					$identifiers = $searchIdentifiers;
				else
					$identifiers = array_intersect($identifiers, $searchIdentifiers);
				if (is_null($identifiers) || !count($identifiers))
					return (array());
			} else
				throw new ApplicationException("Bad value for criterion '$critIndex'.", ApplicationException::API);
		}
		// sorting
		if (!is_null($sort)) {
			foreach ($sort as $sortIndex => $sortType) {
				if (is_numeric($sortIndex) && strcasecmp($sortType, "asc") && strcasecmp($sortType, "desc")) {
					$sortIndex = $sortType;
					$sortType = "asc";
				}
				throw new ApplicationException("Sorting is not supported.", ApplicationException::API);
			}
		}
		// limiting
		if (!is_null($limitOffset) || !is_null($nbrDocuments))
			return (array_slice($identifiers, (is_null($limitOffset) ? 0 : $limitOffset), $nbrDocuments)); 
		return ($identifiers);
	}
}

?>
